﻿using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Security.Cryptography;
using System.Security.Permissions;
using System.Text;
using System.Web;
using System.Web.Mvc;
using Microsoft.Win32;

namespace Com.VikingErik.Mvc.ResumingDownloads
{
    /// <summary>
    /// ResumableDownloadResult is an abstract class to serve as the base class
    /// for ActionResults capable of responding to Content-Range HTTP requests.
    /// </summary>
    public abstract class ResumableDownloadResult : ActionResult
    {

        #region Private Constants
        private const string MULTIPART_BOUNDARY = "<q1w2e3r4t5y6u7i8o9p0>";
        //private const string MULTIPART_CONTENTTYPE = "multipart/byteranges; boundary=" + MULTIPART_BOUNDARY;
        #endregion


        #region Properties
        protected ResumableDownloadRequest ResumableRequest { get; set; }

        protected virtual DateTimeOffset? LastModified
        {
            get
            {
                return null;
            }
        }
        private string _ContentTypeStore = "application/octet-stream";
        protected virtual string ContentType
        {
            get
            {
                return _ContentTypeStore;
            }
            set
            {
                _ContentTypeStore = value;
            }
        }
        protected virtual bool IsRangeRequest
        {
            get
            {
                return (null != ResumableRequest.Ranges && ResumableRequest.Ranges.Length > 0);
            }
        }
        protected virtual bool IsMultipartRequest
        {
            get
            {
                return (null != ResumableRequest.Ranges && ResumableRequest.Ranges.Length > 1);
            }
        }
        protected virtual Stream FileContent
        {
            get
            {
                throw new NotImplementedException();
            }
        }
        private string _FileEntityTag;
        protected virtual string FileEntityTag
        {
            get
            {
                if (!string.IsNullOrEmpty(_FileEntityTag))
                    return _FileEntityTag;

                if (string.IsNullOrEmpty(ResumableRequest.ETag) && !string.IsNullOrEmpty(ResumableRequest.FileName) && FileContent.Length > 0)
                {
                    MD5 md5Haser = MD5.Create();
                    byte[] sourceBytes = UnicodeEncoding.UTF8.GetBytes(string.Format("{0}-{1}", ResumableRequest.FileName, FileContent.Length));
                    byte[] encodedBytes = md5Haser.ComputeHash(sourceBytes);
                    StringBuilder sb = new StringBuilder(encodedBytes.Length * 2);
                    foreach (byte encodedByte in encodedBytes)
                    {
                        sb.Append(encodedByte.ToString("x2"));
                    }

                    _FileEntityTag = sb.ToString();
                }
                else
                {
                    _FileEntityTag = ResumableRequest.ETag;
                }

                return _FileEntityTag;
            }
        }
        #endregion


        #region Public Constructors
        public ResumableDownloadResult(ResumableDownloadRequest resumableDownloadRequest)
        {
            ResumableRequest = resumableDownloadRequest;
        }

        public ResumableDownloadResult(ResumableDownloadRequest resumableDownloadRequest, string contentType)
            : this(resumableDownloadRequest)
        {
            ContentType = contentType;
        }
        #endregion


        #region ActionResult Overrides
        /// <summary>
        /// Main worker method from ActionResult.
        /// </summary>
        /// <param name="context">The executing controller context.</param>
        public override void ExecuteResult(ControllerContext context)
        {
            CorrectByteRanges();
            WriteCommonHeaders(context);

            if (null != ResumableRequest.Precondition)
            {
                DateTimeOffset preconditionDateTime;

                switch (ResumableRequest.Precondition.Type)
                {

                    case PreconditionType.IfMatch:
                        if (!string.IsNullOrEmpty(FileEntityTag))
                        {
                            IEnumerable<string> entitiesTags = (string[])ResumableRequest.Precondition.Value;
                            if (!entitiesTags.Any(entity => entity.Equals("*") || entity.Equals(FileEntityTag)))
                            {
                                context.HttpContext.Response.StatusCode = (int)HttpStatusCode.PreconditionFailed;
                                return;
                            }
                        }
                        break;


                    case PreconditionType.IfModifiedSince:
                        if (LastModified.HasValue && null != ResumableRequest.Precondition.Value)
                        {
                            if ((LastModified.Value - (DateTimeOffset)ResumableRequest.Precondition.Value).TotalSeconds < 1)
                            //if (!LastModified.Value.IsHttpDateLaterThan((DateTimeOffset)ResumableRequest.Precondition.Value))
                            {
                                context.HttpContext.Response.StatusCode = (int)HttpStatusCode.NotModified;
                                return;
                            }
                        }
                        break;


                    case PreconditionType.IfUnmodifiedSince:
                        if (LastModified.HasValue)
                        {
                            if ((LastModified.Value - (DateTimeOffset)ResumableRequest.Precondition.Value).TotalSeconds < 1)
                            {
                                context.HttpContext.Response.StatusCode = (int)HttpStatusCode.PreconditionFailed;
                                return;
                            }
                        }
                        break;


                    case PreconditionType.IfNoneMatch:
                        if (!string.IsNullOrEmpty(FileEntityTag))
                        {
                            IEnumerable<string> entitiesTag = (string[])ResumableRequest.Precondition.Value;
                            if (entitiesTag.Any(entity => entity.Equals("*") || entity.Equals(FileEntityTag)))
                            {
                                if (context.HttpContext.Request.RequestType == "GET" ||
                                    context.HttpContext.Request.RequestType == "HEAD")
                                {
                                    context.HttpContext.Response.StatusCode = (int)HttpStatusCode.NotModified;
                                    return;
                                }
                                else
                                {
                                    context.HttpContext.Response.StatusCode = (int)HttpStatusCode.PreconditionFailed;
                                    return;
                                }
                            }
                        }
                        break;


                    case PreconditionType.IfRange:
                        string checkValue = ResumableRequest.Precondition.Value.ToString();

                        if (DateTimeOffset.TryParse(checkValue, out preconditionDateTime))
                        {
                            if ((LastModified.Value - preconditionDateTime).TotalSeconds > 1)
                            {
                                //The request had a date check; requested entity is newer so we return the full entity
                                ResumableRequest.Ranges = null;
                            }
                        }
                        else
                        {
                            if (!checkValue.Equals(FileEntityTag))
                            {
                                //The request had an entity tag; it didn't match our tag so we return the full entity
                                ResumableRequest.Ranges = null;
                            }
                        }
                        
                        break;

                }
            }

            using (FileContent)
            {
                if (IsRangeRequest)
                    WritePartialContent(context, FileContent);
                else
                    WriteFullContent(context, FileContent);
            }
        }
        #endregion


        #region ResumableDownloadResult Methods
        /// <summary>
        /// Writes the common headers to the response stream.
        /// </summary>
        /// <param name="context">The executing controller context.</param>
        protected virtual void WriteCommonHeaders(ControllerContext context)
        {
            if (IsMultipartRequest)
            {
                context.HttpContext.Response.ContentType = string.Format("multipart/byteranges; boundary={0}", MULTIPART_BOUNDARY);
            }
            else
            {
                context.HttpContext.Response.ContentType = ContentType;
            }

            context.HttpContext.Response.AddHeader(
                HttpWorkerRequest.GetKnownResponseHeaderName(HttpWorkerRequest.HeaderAcceptRanges),
                "bytes");

            if (!string.IsNullOrEmpty(ResumableRequest.FileName))
            {
                context.HttpContext.Response.AddHeader(
                    "Content-Disposition", string.Format("inline; filename=\"{0}\"", ResumableRequest.FileName));
            }

            if (!string.IsNullOrEmpty(FileEntityTag))
            {
                context.HttpContext.Response.AddHeader(
                    HttpWorkerRequest.GetKnownResponseHeaderName(HttpWorkerRequest.HeaderEtag),
                    FileEntityTag);
            }

            if (LastModified.HasValue)
            {
                context.HttpContext.Response.AddHeader(
                    HttpWorkerRequest.GetKnownResponseHeaderName(HttpWorkerRequest.HeaderLastModified),
                    LastModified.Value.ToString("R"));
            }
        }


        /// <summary>
        /// Write the full file contents to the output stream.
        /// </summary>
        /// <param name="context">The executing controller context.</param>
        /// <param name="fileContent">The stream from which the byte data is read.</param>
        public virtual void WriteFullContent(ControllerContext context, Stream fileContent)
        {
            context.HttpContext.Response.StatusCode = (int)HttpStatusCode.OK;

            WriteBinaryData(context, fileContent, 0, fileContent.Length - 1);
        }


        /// <summary>
        /// Write specific byte ranges to the output stream specified by the ResumableDownloadRequest object.
        /// </summary>
        /// <param name="context">The executing controller context.</param>
        /// <param name="fileContent">The stream from which the byte data is read.</param>
        // See http://www.w3.org/Protocols/rfc1341/7_2_Multipart.html for multipart format information
        public virtual void WritePartialContent(ControllerContext context, Stream fileContent)
        {
            var response = context.HttpContext.Response;

            response.StatusCode = (int)HttpStatusCode.PartialContent;

            if (!IsMultipartRequest)
                context.HttpContext.Response.AddHeader(
                    HttpWorkerRequest.GetKnownResponseHeaderName(HttpWorkerRequest.HeaderContentRange),
                    string.Format("bytes {0}-{1}/{2}",
                        ResumableRequest.Ranges.First().StartByte,
                        ResumableRequest.Ranges.First().EndByte,
                        fileContent.Length)
                    );

            foreach (var range in ResumableRequest.Ranges)
            {
                if (!response.IsClientConnected)
                    return;

                if (IsMultipartRequest)
                {
                    response.Output.WriteLine(string.Format("--{0}", MULTIPART_BOUNDARY));
                    response.Output.WriteLine(string.Format("{0}: {1}",
                        HttpWorkerRequest.GetKnownResponseHeaderName(HttpWorkerRequest.HeaderContentType),
                        ContentType));
                    response.Output.WriteLine(string.Format("{0}: bytes {1}-{2}/{3}",
                        HttpWorkerRequest.GetKnownResponseHeaderName(HttpWorkerRequest.HeaderContentRange),
                        ResumableRequest.Ranges.First().StartByte,
                        ResumableRequest.Ranges.First().EndByte,
                        fileContent.Length));
                    response.Output.WriteLine();
                }

                WriteBinaryData(context, fileContent, range.StartByte, range.EndByte);

                if (IsMultipartRequest)
                    response.Output.WriteLine();
            }

            if (IsMultipartRequest)
            {
                response.Output.WriteLine("--" + MULTIPART_BOUNDARY + "--");
                response.Output.WriteLine();
            }

        }

        /// <summary>
        /// Corrects offset requests to real byte locations for the FileContent object.
        /// </summary>
        private void CorrectByteRanges()
        {
            if (null != ResumableRequest.Ranges)
            {
                for (int i = 0; i < ResumableRequest.Ranges.Length; i++)
                {
                    if (ResumableRequest.Ranges[i].EndByte == long.MaxValue)
                        ResumableRequest.Ranges[i].EndByte = FileContent.Length - 1;
                    
                    if (ResumableRequest.Ranges[i].StartByte < 0)
                        ResumableRequest.Ranges[i].StartByte = FileContent.Length + ResumableRequest.Ranges[i].StartByte;
                }
            }
        }

        /// <summary>
        /// Writes byte data from <paramref name="fileContent"/> to the <paramref name="context"/> Response OutputStream
        /// starting at <paramref name="startIndex"/> and ending at <paramref name="endIndex"/> inclusive.
        /// </summary>
        /// <param name="context">The ControllerContext of the ActionResult.</param>
        /// <param name="fileContent">The Stream from which to write data to the Response.OutputStream.</param>
        /// <param name="startIndex">The position from which to start reading content.</param>
        /// <param name="endIndex">The last index position from which to read content.</param>
        private void WriteBinaryData(ControllerContext context, Stream fileContent, long startIndex, long endIndex)
        {
            var response = context.HttpContext.Response;

            byte[] buffer = new byte[0x1000];
            long totalToSend = endIndex - startIndex;
            int count = 0;

            long bytesRemaining = totalToSend + 1; //To make EndIndex inclusive
            fileContent.Seek(startIndex, SeekOrigin.Begin);

            while (response.IsClientConnected && bytesRemaining > 0)
            {
                try
                {
                    if (bytesRemaining <= buffer.Length)
                        count = fileContent.Read(buffer, 0, (int)bytesRemaining);
                    else
                        count = fileContent.Read(buffer, 0, buffer.Length);

                    if (count == 0) //stream content is shorter than expected
                        return;

                    response.OutputStream.Write(buffer, 0, count);

                    bytesRemaining -= count;
                }
                catch (IndexOutOfRangeException)
                {
                    response.Output.Flush();
                    return;
                }
                finally
                {
                    response.Output.Flush();
                }
            }
        }
        #endregion

        #region Content-Type Resolver Helper
        /// <summary>
        /// Helper function to attempt to identify a content type for a file extension.
        /// This seems to be considered unreliable but I can find no other way to provide
        /// this help in IIS. It seems as though Apache has some capability based on the
        /// file's magic numbers but this would only work if the first part of the stream
        /// was available for Apache to make that determination.
        /// </summary>
        /// <param name="fileExtension"></param>
        /// <returns></returns>
        public static string GetMIMEType(string fileExtension)
        {
            // direct mapping which is fast and ensures these extensions are found
            switch (fileExtension)
            {
                case "htm":
                case "html":
                    return "text/html";
                case "js":
                    return "text/javascript"; // registry may return "application/x-javascript"
            }

            // If that failed...
            // looks for a content type with extension
            RegistryKey typeKey = Registry.ClassesRoot.OpenSubKey(@"MIME\Database\Content Type");

            foreach (string keyname in typeKey.GetSubKeyNames())
            {
                RegistryKey curKey = typeKey.OpenSubKey(keyname);
                if (curKey != null)
                {
                    object extension = curKey.GetValue("Extension");
                    if (extension != null)
                    {
                        if (extension.ToString().ToLower() == fileExtension)
                        {
                            return keyname;
                        }
                    }
                }
            }

            // If that failed...
            // see if we can find extension info anywhere in the registry
            RegistryPermission regPerm = new RegistryPermission(RegistryPermissionAccess.Read, @"\\HKEY_CLASSES_ROOT");

            // looks for extension with a content type
            RegistryKey rkContentTypes = Registry.ClassesRoot.OpenSubKey(fileExtension);
            if (rkContentTypes != null)
            {
                object key = rkContentTypes.GetValue("Content Type");
                if (key != null)
                    return key.ToString().ToLower();
            }

            return null;
        }
        #endregion

    }
}
